iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Modern Web

Laravel Filament 從入門到實戰系列 第 24 篇

Day 23:檔案上傳與資料匯入匯出,正式環境常踩的坑

  • 分享至 

  • xImage
  •  

Day 22:資料變多之後,表格為什麼開始變慢 結尾留下一句話,檔案上傳與資料匯入匯出,正式環境常踩的坑,是接下來要處理的內容。今天要處理的正是這句話。

昨天灌了五千筆商品、一萬筆訂單進資料庫,親眼看到列表頁從順暢變遲滯,再靠關聯預先載入跟資料庫索引把速度找回來。但那段過程裡有一個細節被刻意放在一邊沒有深究,這五千筆商品是怎麼進到資料庫的。答案是 PerformanceTestSeeder,一段跑在終端機裡的 PHP 程式碼,管理者完全碰不到,也不該碰。真正的批發商後台,商品資料不會是工程師寫程式灌進去的,是管理者自己一筆一筆建立,或是拿著供應商給的一份 Excel 表格,想辦法一次匯進系統。這件事目前的示範專案完全沒有處理過。

另一個缺口埋得更早。Day 04:表單欄位全解析,從文字輸入到日期選擇器 那天,商品表單補齊了文字、數字、選擇、日期四類欄位,名稱、規格描述、價格、庫存數量、分類、上架狀態、進貨日期,每個欄位都選對了型別。但七個欄位裡沒有一個能放照片。管理者如果想在後台看到商品長什麼樣子,目前完全沒有辦法,只能另外找地方存圖片,再手動把連結貼進規格描述裡,這種做法從那天以來就一直被擱置到現在。

今天要補齊的是三件事:替商品表單加上圖片上傳欄位,讓商品資料能一次匯入而不是一筆一筆手動輸入,以及讓訂單資料能匯出成外部檔案交給其他人使用。這三件事都建立在既有的商品、訂單模組之上,不新增任何新模組,做的是把後台真正需要的檔案處理能力補上。

商品照片上傳,看起來簡單但要注意的細節

先處理最直觀的需求,替商品表單加上一個圖片上傳欄位。Filament 的 FileUpload 元件本身就支援圖片,加上 image() 之後,介面會直接變成拖曳上傳搭配預覽縮圖,而不是一般檔案選擇器:

use Filament\Forms\Components\FileUpload;

FileUpload::make('photo')
    ->label('商品照片')
    ->image()
    ->disk('public')
    ->directory('products')
    ->imageEditor()

disk('public') 指定檔案存進哪一個 Laravel 檔案系統磁碟,directory('products') 則是在這個磁碟底下開一個子目錄專門放商品照片,避免所有上傳檔案全部堆在同一層。imageEditor() 打開之後,使用者上傳圖片時能就地裁切、旋轉,不需要先在其他軟體處理好尺寸再上傳。表單這端補完之後,列表頁跟檢視頁也該讓管理者看到照片,不能只在編輯畫面才看得到,ImageColumn 負責這件事:

use Filament\Tables\Columns\ImageColumn;

ImageColumn::make('photo')
    ->label('照片')
    ->disk('public')
    ->square()

這裡刻意把 disk('public') 重複寫在表單欄位跟表格欄位兩個地方,而不是只設定一次就想當然耳兩邊共用,這正是本節要點出的第一個容易被忽略的細節,檔案儲存位置。本機開發環境下,public 磁碟對應到專案裡的 storage/app/public 目錄,執行過 php artisan storage:link 之後,這個目錄會被軟連結到 public/storage,瀏覽器才能直接讀到裡面的檔案。這套機制在本機測試起來完全無感,上傳、顯示、重新整理頁面,一切正常。

問題出在正式環境部署之後。如果伺服器用的是容器化部署,每次部署都重新建立一個全新的容器,或者後台架在多台伺服器後面用負載平衡分流,本機硬碟儲存方式會直接出問題。使用者上傳照片存進其中一台伺服器的本機硬碟,下一次請求被負載平衡分到另一台伺服器,那台伺服器的硬碟裡根本沒有這張照片,畫面上就是一個顯示不出來的破圖。容器化部署更極端,容器重新部署之後,舊容器裡存的檔案直接消失,等於商品照片全部憑空不見。這就像本機開發是在自己家裡試穿衣服,鏡子只有一面,怎麼看都順眼,正式環境是走上伸展台,同一件衣服換了場地、換了燈光,才會第一次看出剪裁哪裡出了問題。本機測試感受不到的落差,往往就是部署後第一個爆掉的坑。

真正要在正式環境穩定運作,通常得把 disk 換成物件儲存服務,例如 S3 相容的雲端儲存,讓檔案脫離單一伺服器的本機硬碟,不管請求落在哪一台伺服器,讀到的都是同一份雲端上的檔案。這件事在 Laravel 裡只需要換一組檔案系統設定,FileUpload 跟 ImageColumn 的程式碼幾乎不用改,把 disk('public') 換成設定好的雲端磁碟名稱即可,這正是 Laravel 檔案系統把儲存細節抽象出來的好處,這裡不重新展開檔案系統設定本身怎麼寫,只需要記住一件事,本機用什麼磁碟測試方便,正式環境不能想當然耳照搬同一套。

第二個容易被忽略的細節,是檔案格式與大小限制。如果沒有限制,使用者可能上傳一張十幾 MB 的原始照片,拖慢整個表單的上傳速度,也占用大量儲存空間,甚至可能誤選一個非圖片格式的檔案上傳,導致 ImageColumn 顯示異常。這些限制同樣是 FileUpload 本身附帶的能力,補上去只是幾行設定:

FileUpload::make('photo')
    ->label('商品照片')
    ->image()
    ->disk('public')
    ->directory('products')
    ->imageEditor()
    ->maxSize(2048)
    ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/webp'])

maxSize(2048) 把單一檔案限制在 2MB 以內,acceptedFileTypes() 則只允許幾種常見的圖片格式,使用者選檔案時,不符合條件的檔案在選取當下就會被擋下,不會等到送出表單才收到一則遲來的錯誤訊息。這跟 Day 04:表單欄位全解析,從文字輸入到日期選擇器 那天替日期欄位設定 maxDate() 是同一種手感,讓錯誤在輸入當下就不可能發生,而不是事後才被攔截。

商品批次匯入,一次寫入幾百筆資料要注意的細節

照片補齊之後,回到本篇真正的主戲,批次匯入。管理者手上如果有一份供應商提供的商品清單,裡面列了幾百筆商品的名稱、價格、庫存數量,理想的操作應該是直接把這份檔案丟給系統,系統自動把每一列資料寫成一筆商品記錄,而不是照著清單一筆一筆在表單裡重新打一次。

Filament 內建的匯入機制,是先定義一個 Importer 類別,描述匯入檔案裡每一欄該對應到商品的哪個欄位,以及對應的驗證規則:

namespace App\Filament\Imports;

use App\Models\Product;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;

class ProductImporter extends Importer
{
    protected static ?string $model = Product::class;

    public static function getColumns(): array
    {
        return [
            ImportColumn::make('name')
                ->label('商品名稱')
                ->requiredMapping()
                ->rules(['required', 'max:255']),
            ImportColumn::make('price')
                ->label('價格')
                ->numeric()
                ->rules(['required', 'numeric', 'min:0']),
            ImportColumn::make('stock')
                ->label('庫存數量')
                ->numeric()
                ->rules(['required', 'integer', 'min:0']),
            ImportColumn::make('category')
                ->label('分類')
                ->rules(['nullable', 'in:food,toy,cleaning']),
        ];
    }

    public function resolveRecord(): ?Product
    {
        return new Product();
    }
}

requiredMapping() 確保使用者上傳檔案時,這一欄必須對應到檔案裡的某一欄,不能跳過。rules() 掛的是跟 Day 04:表單欄位全解析,從文字輸入到日期選擇器 表單欄位幾乎相同的驗證邏輯,價格必須是數字、庫存必須是整數,這正是本節要點出的核心細節,格式驗證不會因為資料是批次匯入就變得寬鬆,該擋的規則一條都不能少。

定義好 Importer 之後,把它掛到商品 Resource 的表格頁首:

use Filament\Actions\Imports\ImportAction;

public static function table(Table $table): Table
{
    return $table
        ->headerActions([
            ImportAction::make()
                ->importer(ProductImporter::class),
        ])
        ->columns([
            // 欄位定義沿用既有內容,不重複列出
        ]);
}

管理者點開商品列表頁,會看到一顆匯入按鈕,上傳一份 CSV 或試算表檔案,系統會先讓使用者把檔案裡的欄位對應到 ProductImporter 定義的欄位,接著開始逐列處理。

這裡要正面回答一個問題,如果檔案裡某一列資料格式不對,例如價格欄位填了文字而非數字,整批匯入該怎麼辦。海關檢查一整批貨櫃,其中一箱申報不實,實務上不會把整個貨櫃全部扣下,也不會睜一隻眼閉一隻眼直接放行,而是把那一箱單獨挑出來複查,其餘正常的貨物照常放行。Filament 的匯入機制採取的正是這種處理方式,驗證規則命中的那一列會被單獨標記為失敗並記錄下具體錯誤原因,格式正確的其他列照常寫入資料庫,不會因為一列出錯就讓整批全部作廢,也不會讓錯誤資料悄悄溜進系統。匯入結束後,系統會回報總共處理了幾列、成功幾列、失敗幾列,失敗的部分連同錯誤原因一併提供,管理者可以照著錯誤清單回頭修正原始檔案,再針對失敗的部分重新匯入一次。

flowchart TD
    A[上傳匯入檔案] --> B[欄位對應<br/>檔案欄位對應到<br/>Importer 定義欄位]
    B --> C[逐列驗證<br/>依 rules 規則檢查]
    C --> D{該列格式<br/>是否正確}
    D -->|正確| E[寫入資料庫<br/>成為一筆商品記錄]
    D -->|錯誤| F[標記為失敗<br/>記錄具體錯誤原因]
    E --> G[回報結果<br/>總列數/成功數/失敗數]
    F --> G
    G --> H[管理者依錯誤清單<br/>修正原始檔案]
    H --> I[針對失敗列<br/>重新匯入一次]

匯入這件事還有一層容易被忽略的成本,Day 22:資料變多之後,表格為什麼開始變慢 已經處理過大量資料寫入時的效能考量,匯入幾百筆商品資料,跟灌入五千筆測試資料本質上是同一件事,都是短時間內大量寫入資料庫。逐列驗證加上逐列寫入,資料量一大同樣會拖慢整個匯入過程,甚至可能讓網頁請求逾時。Filament 的匯入機制底層是透過 Laravel 的佇列機制分批處理,而不是在同一個請求裡把幾百筆資料一次跑完,這正是延續 Day 22 已經建立的思路,大量資料處理不能靠同步、逐筆的方式硬扛,得交給背景處理分批消化,具體的佇列設定與批次處理原理這裡不重新展開。

訂單資料匯出,讓報表需求先有個雛形

匯入處理的是外部資料進到系統,匯出則是反過來,把系統內既有的訂單資料轉換成外部檔案,交給業務人員帶出去談生意,或是丟給財務人員做進一步的帳務核對。這件事比匯入單純不少,不用面對格式驗證失敗這種進退兩難的處境,系統裡的資料本來就是驗證過的,要做的只是把它轉換成另一種檔案格式。

跟匯入的寫法對稱,先定義一個 Exporter 類別,描述要匯出哪些欄位:

namespace App\Filament\Exports;

use App\Models\Order;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;

class OrderExporter extends Exporter
{
    protected static ?string $model = Order::class;

    public static function getColumns(): array
    {
        return [
            ExportColumn::make('id')
                ->label('訂單編號'),
            ExportColumn::make('customer.name')
                ->label('客戶名稱'),
            ExportColumn::make('status')
                ->label('訂單狀態'),
            ExportColumn::make('order_date')
                ->label('訂單日期'),
            ExportColumn::make('total_amount')
                ->label('訂單金額'),
        ];
    }
}

customer.name 這種寫法直接沿用 Day 10:表格裡顯示關聯資料,一眼看出這張訂單屬於哪個客戶 定案的關聯取值方式,匯出的欄位可以直接取用關聯資料,不需要另外處理。掛到訂單列表頁的方式也跟匯入對稱:

use Filament\Actions\Exports\ExportAction;

public static function table(Table $table): Table
{
    return $table
        ->headerActions([
            ExportAction::make()
                ->exporter(OrderExporter::class),
        ])
        ->columns([
            // 欄位定義沿用既有內容,不重複列出
        ]);
}

管理者點開訂單列表頁,先用 Day 07:讓大量資料可用,篩選、排序與搜尋 已經建立的篩選機制把想要的訂單範圍縮小,例如只篩出上個月的訂單,再點下匯出按鈕,系統會照著目前畫面上實際套用的篩選與排序條件產生檔案,而不是無條件把整張訂單表全部倒出來。這一點值得特別說明,如果訂單累積到 Day 22:資料變多之後,表格為什麼開始變慢 灌入的一萬筆規模,管理者卻沒有先篩選就直接匯出全部資料,系統得一次讀取並轉換一萬筆記錄成檔案內容,同樣會遇到跟批次匯入一樣的效能負擔。處理原則延續 Day 22 已經建立的大量資料處理思路,匯出動作同樣是交給背景處理,完成後再通知管理者下載,不會讓瀏覽器停在原地空等一個可能長達數十秒的請求。

匯出功能相對單純,不代表可以不假思索地把所有欄位一股腦全部塞進去。訂單裡如果有些欄位屬於系統內部使用,例如租戶識別欄位這類跟 Day 17:多租戶隔離,讓每個經銷據點只看到自己的資料 定案的隔離機制相關的內部欄位,通常不會出現在給業務或財務看的匯出檔案裡,Exporter 只描述真正需要對外呈現的欄位,這也是為什麼匯出邏輯要獨立成一個類別,而不是直接把表格欄位定義原封不動搬過去用。

功能齊了,手動點擊驗證撐得住多久

回顧今天補上的三個功能,商品照片上傳、商品批次匯入、訂單資料匯出,示範專案第一次具備完整的檔案處理能力。從 Day 4 就被擱置的商品照片缺口補上了,昨天在 Day 22 結尾點出的批次建檔需求也有了對應的解法,訂單資料匯出則讓報表需求有了一個雛形,這幾件事都建立在既有的商品、訂單模組之上,沒有引入任何新模組。

但今天走完這三個功能的過程裡,得誠實承認一件事,每一步的驗證方式都是手動操作。上傳一張圖片,看列表頁縮圖有沒有正常顯示;準備一份包含幾筆錯誤資料的檔案去匯入,看系統有沒有正確攔下那幾列並回報錯誤;點下匯出按鈕,打開下載回來的檔案核對欄位對不對。這種驗證方式在今天這個當下夠用,因為程式碼是剛寫完、邏輯還新鮮地印在腦子裡,手動點一輪確實能確認功能正常。

問題在於明天以後。這幾個功能裡,匯入功能的格式驗證邏輯特別脆弱,價格必須是數字、庫存必須是整數、分類必須落在指定選項之內,這些規則今天寫對了,不代表半年後某次改動商品欄位、調整驗證規則時,還會維持同樣正確。手動驗證方式沒辦法確保未來每一次修改程式碼,都不會不小心破壞這幾個已經做好的功能,尤其是匯入這一段,一旦驗證邏輯不小心被改壞,很可能要等到正式環境真的有人上傳了一批格式錯誤的商品資料,系統卻沒有攔下來,寫進去一批壞資料,才會第一次發現問題出在哪裡,而那時候要回頭清理已經寫進資料庫的髒資料,成本遠比現在寫一次正確的驗證規則高上許多。

幫 Panel 寫測試,Testable 方法組合怎麼用,是接下來要處理的內容。


上一篇
Day 22:資料變多之後,表格為什麼開始變慢
系列文
Laravel Filament 從入門到實戰 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言